在 Kubernetes 发展初期,部署 Kubernetes 一直是一件让初学者头疼的事情,Kubernetes 也开始重视这个问题,2017年,在社区志愿者的推动下,社区发起了一个独立的部署 Kubernetes 的项目,kubeadm 。
经过一年多的发展,kubeadm 已经可以一键式进行 Kubernetes 集群的快速初始化和安装,极大地简化了部署过程。值得一提的是,在很长一段时间里 kubeadm 有个比较欠缺的地方是无法做到一键部署一个高可用的 Kubernetes 集群,这是 kubeadm 目前的工作重点,好在这个功能已经在 1.11 版本刚刚发布,可以参考 Creating Highly Available Clusters with kubeadm 。
原理 Kubernetes 是一个管理容器的平台,所以用容器来部署自身的组件是一件自然而然的事情,kube-apiserver, scheduler 等组件还好解决,比较麻烦的是 kubelet 组件,因为 kubelet 需要承担与容器运行时交互的工作,还需要解决容器运行时网络,存储的配置问题,这些都需要直接操作宿主机的,如果 kubelet 本身就运行在容器里,这些问题倒也不是不能解决,只是需要做各种方式的 hack,部署 Kubernetes 的这个需求会变得更为复杂。kubeadm 的方案是 kubelet 直接运行在宿主机上,其他的 Kubernetes 组件使用容器部署。
所以使用 kubeadm 的第一步手动安装 kubeadm,kubelet,kubectl 这3个二进制文件,可以参考我的 k8s-digitalocean-terraform 项目,里面有需要安装的依赖。 这是运行 kubeadm 的前提。
基本流程 kubeadm 中比较关键的工作流程就两步,1. kubeadm init
进行初始化。2. kubeadm join master-ip:master:port
将 worker 节点连接到 master 节点,组成集群。
kubeadm init
以下内容均考虑使用默认参数的情况。
kubeadm init
首先会做一系列的检查工作,以确保当前机器是可以部署 Kubernetes 的。这一步叫做 Preflight Checks,包括:
Linux 内核版本是否在 3.10 以上
Linux cgroups 是否可用
hostname 是否符合 DNS 命名规范(RFC 1123)
kubeadm 和 kubelet 版本是否匹配
是否安装了 Kubernetes 的二进制文件
10250,10251,10252 端口是否可用
ip、mount 工具是否已经存在
是否安装了 Docker
接着 kubeadm 会生成 Kubernetes 对外提供服务所需的安全证书。
这个过程完成后,kubeadm 会为 Master 组件生成 Pod 的 yaml 格式的配置文件,存放在 /etc/kubernetes/manifests
,kubelet 会使用一种叫做 Static Pod 的特殊的容器启动方法,根据 yaml 格式文件在机器上启动 kube-apiserver、kube-controller-manager、kube-scheduler、etcd 这四个组件。kubeadm 可以根据配置使用已有的 etcd 集群,不一定非得自己创建。
kubeadm 会监控 /etc/kubernetes/manifests
目录下的内容,一旦生成 yaml 文件,kubelet 就会创建这些 yaml 定义的 Pod,Master 节点的各个组件启动之后,kubeadm 会检查 localhost:6443/healthz
健康状态,直到 Master 节点完全运行起来。
再接下来 kubeadm 会为集群生成一个 bootstrap token,worker 节点可以通过这个 token 加入到集群里。生成 token 后,master 节点的重要信息会通过 cluster-info 这个 ConfigMap 保存到 etcd 里。
最后 kubeadm 会安装插件,有两个默认的必装的插件,一个是 kube-proxy,默认使用 iptable,未来 ipvs 会是默认选项;另一个是 DNS,默认安装 kubedns + dnsmsaq,未来趋势是转向 CoreDNS。插件会通过 Kubernetes 客户端进行创建。
kubeadm join
其他节点加入到 Master 节点需要保证机器之间是网络互通的,这是将来容器之间网络互通的前提。执行完 kubeadm init
之后可以看到 kubeadm join
的使用方法,在其他机器上运行这行命令即可。
kubeadm join
也会检测依赖环境,相比于 kubeadm init
要简单得多,通过检测时候,根据 token 从 kube-apiserver 拿到 cluster-info
,获取 kube-apiserver 的证书,这时 kubelet 就接入了集群中。
如果机器很多,手工操作当然不酷,自动化才是未来,可以参考一下 结合 Terraform 在 DigitalOcean 上一键式部署 Kubernetes 这个项目。
代码解析 上面已经把流程描述了一遍,可以看出来,就代码实现来说,不会有什么很复杂的东西,其实代码里也真的是一股脑一个流程走到底,尽管如此我们还是继续研读一下代码,挖掘一些有价值的东西。
基于 v1.10.0-rc.1 代码版本进行分析。
kubeadm init init
方法的入口在 cmd/kubeadm/app/cmd/init.go
在 cobra 对应的 init 子命令下,先调用 NewInit
方法,进行 Preflight Checks 操作,确保本机可以运行 kubeadm,接着调用 Init
的 Run
方法进行 Master 节点的初始化操作。
系统状态检查 在 NewInit
里会调用 RunInitMasterChecks
用来完成安装前的检测,这部分内容都在 cmd/kubeadm/preflight/checks.go
里。核心逻辑是定义一堆 checks,然后进行变量,全部通过则满足 Preflight Checks
的要求。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 checks := []Checker{ KubernetesVersionCheck{KubernetesVersion: cfg.KubernetesVersion, KubeadmVersion: kubeadmversion.Get().GitVersion}, SystemVerificationCheck{CRISocket: cfg.CRISocket}, IsPrivilegedUserCheck{}, HostnameCheck{nodeName: cfg.NodeName}, KubeletVersionCheck{KubernetesVersion: cfg.KubernetesVersion, exec: execer}, ServiceCheck{Service: "kubelet" , CheckIfActive: false }, FirewalldCheck{ports: []int {int (cfg.API.BindPort), 10250 }}, PortOpenCheck{port: int (cfg.API.BindPort)}, FileAvailableCheck{Path: kubeadmconstants.GetStaticPodFilepath(kubeadmconstants.KubeAPIServer, manifestsDir)}, InPathCheck{executable: "ip" , mandatory: true , exec: execer}, criCtlChecker, ExtraArgsCheck{ APIServerExtraArgs: cfg.APIServerExtraArgs, ControllerManagerExtraArgs: cfg.ControllerManagerExtraArgs, SchedulerExtraArgs: cfg.SchedulerExtraArgs, }, HTTPProxyCheck{Proto: "https" , Host: cfg.API.AdvertiseAddress}, }
生成证书 生成证书的操作在 Init
的 Run
方法里的 PHASE 1。调用 certsphase.CreatePKIAssets
实现。
这部分代码在 cmd/kubeadm/app/cmd/phases/certs.go
里,依次调用了一下函数:
1 2 3 4 5 6 7 8 9 10 11 CreateCACertAndKeyFiles, CreateAPIServerCertAndKeyFiles, CreateAPIServerKubeletClientCertAndKeyFiles, CreateEtcdCACertAndKeyFiles, CreateEtcdServerCertAndKeyFiles, CreateEtcdPeerCertAndKeyFiles, CreateEtcdHealthcheckClientCertAndKeyFiles, CreateAPIServerEtcdClientCertAndKeyFiles, CreateServiceAccountKeyAndPublicKeyFiles, CreateFrontProxyCACertAndKeyFiles, CreateFrontProxyClientCertAndKeyFiles,
为确保安全,Kubernetes 的各个组件需要使用 x509
证书对通信进行加密和认证,如果是使用手动安装的话,整个过程很复杂,所以 kbeadm 将这个过程自动化了。
首先通过 CreateCACertAndKeyFiles
创建 CA,用来签名后续创建的其他证书,对证书进行管理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 func CreateCACertAndKeyFiles(cfg *kubeadmapi.MasterConfiguration) error { caCert, caKey, err := NewCACertAndKey() if err != nil { return err } return writeCertificateAuthorithyFilesIfNotExist( cfg.CertificatesDir, kubeadmconstants.CACertAndKeyBaseName, caCert, caKey, ) } func NewCACertAndKey() (*x509.Certificate, *rsa.PrivateKey, error) { caCert, caKey, err := pkiutil.NewCertificateAuthority() if err != nil { return nil, nil, fmt.Errorf("failure while generating CA certificate and key: %v", err) } return caCert, caKey, nil }
pkiutil 模块的 NewCertificateAuthority
在 cmd/kubeadm/app/phases/certs/pkiutil/pki_helpers.go
里。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func NewCertificateAuthority() (*x509.Certificate, *rsa.PrivateKey, error) { key, err := certutil.NewPrivateKey() if err != nil { return nil, nil, fmt.Errorf("unable to create private key [%v]", err) } config := certutil.Config{ CommonName: "kubernetes", } cert, err := certutil.NewSelfSignedCACert(config, key) if err != nil { return nil, nil, fmt.Errorf("unable to create self-signed certificate [%v]", err) } return cert, key, nil }
私钥通过 k8s.io/client-go/util/cert
包里的函数实现,其中 NewPrivateKey
只是对 rsa 库函数的封装。
以 CreateAPIServerCertAndKeyFiles
为例,我们再看 kube-apiserver 的证书是怎么生成的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 func CreateAPIServerCertAndKeyFiles(cfg *kubeadmapi.MasterConfiguration) error { caCert, caKey, err := loadCertificateAuthority(cfg.CertificatesDir, kubeadmconstants.CACertAndKeyBaseName) if err != nil { return err } apiCert, apiKey, err := NewAPIServerCertAndKey(cfg, caCert, caKey) if err != nil { return err } return writeCertificateFilesIfNotExist( cfg.CertificatesDir, kubeadmconstants.APIServerCertAndKeyBaseName, caCert, apiCert, apiKey, ) } func NewAPIServerCertAndKey(cfg *kubeadmapi.MasterConfiguration, caCert *x509.Certificate, caKey *rsa.PrivateKey) (*x509.Certificate, *rsa.PrivateKey, error) { altNames, err := pkiutil.GetAPIServerAltNames(cfg) if err != nil { return nil, nil, fmt.Errorf("failure while composing altnames for API server: %v", err) } config := certutil.Config{ CommonName: kubeadmconstants.APIServerCertCommonName, AltNames: *altNames, Usages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, } apiCert, apiKey, err := pkiutil.NewCertAndKey(caCert, caKey, config) if err != nil { return nil, nil, fmt.Errorf("failure while creating API server key and certificate: %v", err) } return apiCert, apiKey, nil } func NewCertAndKey(caCert *x509.Certificate, caKey *rsa.PrivateKey, config certutil.Config) (*x509.Certificate, *rsa.PrivateKey, error) { key, err := certutil.NewPrivateKey() if err != nil { return nil, nil, fmt.Errorf("unable to create private key [%v]", err) } cert, err := certutil.NewSignedCert(config, key, caCert, caKey) if err != nil { return nil, nil, fmt.Errorf("unable to sign certificate [%v]", err) } return cert, key, nil }
大致流程和生成 ca 时类似,只是进行配置时需要生成 AltName,这是 master 的地址;最后生成证书是需要用 ca 添加数字签名,生成证书。
生成 master 各组件所需的 kubeconfig 这部分代码在 cmd/kubeadm/app/phases/kubeconfig/kubeconfig.go
里。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 func CreateInitKubeConfigFiles(outDir string, cfg *kubeadmapi.MasterConfiguration) error { return createKubeConfigFiles( outDir, cfg, kubeadmconstants.AdminKubeConfigFileName, kubeadmconstants.KubeletKubeConfigFileName, kubeadmconstants.ControllerManagerKubeConfigFileName, kubeadmconstants.SchedulerKubeConfigFileName, ) } func createKubeConfigFiles(outDir string, cfg *kubeadmapi.MasterConfiguration, kubeConfigFileNames ...string) error { specs, err := getKubeConfigSpecs(cfg) if err != nil { return err } for _, kubeConfigFileName := range kubeConfigFileNames { spec, exists := specs[kubeConfigFileName] if !exists { return fmt.Errorf("couldn't retrive KubeConfigSpec for %s", kubeConfigFileName) } config, err := buildKubeConfigFromSpec(spec) if err != nil { return err } if err = createKubeConfigFileIfNotExists(outDir, kubeConfigFileName, config); err != nil { return err } } return nil }
首先根据配置获取 spec,然后根据根据 spec 依次生成 admin.conf
, controller-manager.conf
, kubelet.conf
, scheduler.conf
文件。
生成 Static Pod 静态文件 前面已经提过原理,kubelet 会监控 /etc/kubernetes/manifests
目录,加载所有的 Pod Yaml 文件,然后在本机上启动这些 Pod。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 func createStaticPodFiles(manifestDir string, cfg *kubeadmapi.MasterConfiguration, componentNames ...string) error { k8sVersion, err := version.ParseSemantic(cfg.KubernetesVersion) if err != nil { return err } specs := GetStaticPodSpecs(cfg, k8sVersion) for _, componentName := range componentNames { spec, exists := specs[componentName] if !exists { return fmt.Errorf("couldn't retrive StaticPodSpec for %s", componentName) } if err := staticpodutil.WriteStaticPodToDisk(componentName, manifestDir, spec); err != nil { return fmt.Errorf("failed to create static pod manifest file for %q: %v", componentName, err) } fmt.Printf("[controlplane] Wrote Static Pod manifest for component %s to %q\n", componentName, kubeadmconstants.GetStaticPodFilepath(componentName, manifestDir)) } return nil }
如果没有配置外部的 etcd 集群,默认情况下也会根据这种方式创建 etcd。原理一样,不过代码在 cmd/kubeadm/app/phases/etcd/local.go
等待 master 启动 接下来的操作需要等待 master 的组件完全启动后才能进行,这一步需要拉取 kube-apiserver, kube-scheduler, kube-controller-manager, etcd 的镜像,可能会花费比较长的时间。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 func waitForAPIAndKubelet(waiter apiclient.Waiter) error { errorChan := make(chan error) fmt.Printf("[init] Waiting for the kubelet to boot up the control plane as Static Pods from directory %q.\n", kubeadmconstants.GetStaticPodDirectory()) fmt.Println("[init] This might take a minute or longer if the control plane images have to be pulled.") go func(errC chan error, waiter apiclient.Waiter) { if err := waiter.WaitForHealthyKubelet(40*time.Second, "http://localhost:10255/healthz"); err != nil { errC <- err } }(errorChan, waiter) go func(errC chan error, waiter apiclient.Waiter) { if err := waiter.WaitForHealthyKubelet(60*time.Second, "http://localhost:10255/healthz/syncloop"); err != nil { errC <- err } }(errorChan, waiter) go func(errC chan error, waiter apiclient.Waiter) { errC <- waiter.WaitForAPI() }(errorChan, waiter) return <-errorChan }
kubeadm 会不断检查 http://localhost:6443/healthz
这个健康检查的接口 以及 http://localhost:10255/healthz
, http://localhost:10255/healthz/syncloop
这两个 kubelet 的可读接口,等待 Master 组件完全运行起来。
存储 ConfigMap 这一步没什么特别的,通过 api-client 把 Master 的配置上传到 Kubernetes 的 ConfigMap。代码在 cmd/kubeadm/app/phases/uploadconfig/uploadconfig.go
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 func UploadConfiguration(cfg *kubeadmapi.MasterConfiguration, client clientset.Interface) error { fmt.Printf("[uploadconfig] Storing the configuration used in ConfigMap %q in the %q Namespace\n", kubeadmconstants.MasterConfigurationConfigMap, metav1.NamespaceSystem) externalcfg := &kubeadmapiext.MasterConfiguration{} legacyscheme.Scheme.Convert(cfg, externalcfg, nil) externalcfg.Token = "" cfgYaml, err := yaml.Marshal(*externalcfg) if err != nil { return err } return apiclient.CreateOrUpdateConfigMap(client, &v1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: kubeadmconstants.MasterConfigurationConfigMap, Namespace: metav1.NamespaceSystem, }, Data: map[string]string{ kubeadmconstants.MasterConfigurationConfigMapKey: string(cfgYaml), }, }) }
其中 apiclient 是 kubeadm 对 api-client 的简单封装,代码在 cmd/kubeadm/app/util/apiclient/idempotency.go
创建 token 生成的这个 token 让 worker 节点有权限可以拿到 cluster-info 这个 ConfigMap 里的信息,包括 api-server 的地址,认证信息等。
这部分代码在 cmd/kubeadm/app/phases/bootstraptoken/node/token.go
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 func UpdateOrCreateToken(client clientset.Interface, token string, failIfExists bool, tokenDuration time.Duration, usages []string, extraGroups []string, description string) error { tokenID, tokenSecret, err := tokenutil.ParseToken(token) if err != nil { return err } secretName := fmt.Sprintf("%s%s", bootstrapapi.BootstrapTokenSecretPrefix, tokenID) var lastErr error for i := 0; i < tokenCreateRetries; i++ { secret, err := client.CoreV1().Secrets(metav1.NamespaceSystem).Get(secretName, metav1.GetOptions{}) if err == nil { if failIfExists { return fmt.Errorf("a token with id %q already exists", tokenID) } tokenSecretData, err := encodeTokenSecretData(tokenID, tokenSecret, tokenDuration, usages, extraGroups, description) if err != nil { return err } secret.Data = tokenSecretData if _, err := client.CoreV1().Secrets(metav1.NamespaceSystem).Update(secret); err == nil { return nil } lastErr = err continue } if apierrors.IsNotFound(err) { tokenSecretData, err := encodeTokenSecretData(tokenID, tokenSecret, tokenDuration, usages, extraGroups, description) if err != nil { return err } secret = &v1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: secretName, }, Type: v1.SecretType(bootstrapapi.SecretTypeBootstrapToken), Data: tokenSecretData, } if _, err := client.CoreV1().Secrets(metav1.NamespaceSystem).Create(secret); err == nil { return nil } lastErr = err continue } } return fmt.Errorf( "unable to create bootstrap token after %d attempts [%v]", tokenCreateRetries, lastErr, ) }
通过 Kubernetes 的 secret 创建。
安装 dns,kube-proxy 插件 dns,kube-proxy 是 Kubernetes 里默认的,必装的插件。
coreDNS 的 Deployment, ConfigMap, ClusterRole 等 yaml 配置直接用字符串定义在 cmd/kubeadm/app/phases/addons/dns/manifests.go
文件里。
代码基本就是根据配置渲染模板,通过 Kubernetes 的 api 创建,没什么特别的。
kube-proxy 的情况类似,yaml 配置在 cmd/kubeadm/app/phases/addons/proxy/manifests.go
文件里定义。
kubeadm join join 方法跟 init 方法类似,但要简单得多,首先调用 NewJoin
,先调用 RunJoinNodeChecks
来完成初始化前的检测,然后调用 Run
方法将本节点加入到集群里。
系统状态检查 worker 节点的 preflight checks 跟 master 节点的略微不同。
1 2 3 4 5 6 7 8 9 10 11 checks := []Checker{ SystemVerificationCheck{CRISocket: cfg.CRISocket}, IsPrivilegedUserCheck{}, HostnameCheck{cfg.NodeName}, KubeletVersionCheck{exec: execer}, ServiceCheck{Service: "kubelet", CheckIfActive: false}, PortOpenCheck{port: 10250}, DirAvailableCheck{Path: filepath.Join(kubeadmconstants.KubernetesDir, kubeadmconstants.ManifestsSubDirName)}, FileAvailableCheck{Path: cfg.CACertPath}, // ... }
除了一些常规的检测外,worker 节点还需要确保 ip, ipables, mount 命令已经安装好了。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 if runtime.GOOS == "linux" { checks = append(checks, FileContentCheck{Path: bridgenf, Content: []byte{'1'}}, SwapCheck{}, InPathCheck{executable: "ip", mandatory: true, exec: execer}, InPathCheck{executable: "iptables", mandatory: true, exec: execer}, InPathCheck{executable: "mount", mandatory: true, exec: execer}, InPathCheck{executable: "nsenter", mandatory: true, exec: execer}, InPathCheck{executable: "ebtables", mandatory: false, exec: execer}, InPathCheck{executable: "ethtool", mandatory: false, exec: execer}, InPathCheck{executable: "socat", mandatory: false, exec: execer}, InPathCheck{executable: "tc", mandatory: false, exec: execer}, InPathCheck{executable: "touch", mandatory: false, exec: execer}, criCtlChecker) }
加入集群 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 func (j *Join) Run(out io.Writer) error { cfg, err := discovery.For(j.cfg) if err != nil { return err } kubeconfigFile := filepath.Join(kubeadmconstants.KubernetesDir, kubeadmconstants.KubeletBootstrapKubeConfigFileName) if err := kubeconfigutil.WriteToDisk(kubeconfigFile, cfg); err != nil { return fmt.Errorf("couldn't save bootstrap-kubelet.conf to disk: %v", err) } cluster := cfg.Contexts[cfg.CurrentContext].Cluster err = certutil.WriteCert(j.cfg.CACertPath, cfg.Clusters[cluster].CertificateAuthorityData) if err != nil { return fmt.Errorf("couldn't save the CA certificate to disk: %v", err) } if features.Enabled(j.cfg.FeatureGates, features.DynamicKubeletConfig) { if err := kubeletphase.ConsumeBaseKubeletConfiguration(j.cfg.NodeName); err != nil { return fmt.Errorf("error consuming base kubelet configuration: %v", err) } } fmt.Fprintf(out, joinDoneMsgf) return nil }
join 命令会调用 func (j *Join) Run(out io.Writer) error
方法,先根据 token 获取 cluster-info
里的信息,写入 bootstrap-kubelet.conf
文件,然后向 Master 提交一个 certificate signing request (CSR),通过后 worker 节点保存 ca.crt
和 kubelet.conf
文件,bootstrap-kubelet.conf
会被删除,这时应用 kubelet.conf
本节点就加入了集群。
总结 kubeadm 是 Kubernetes 项目的原生部署工具,使用 kubeadm 部署一个 Kubernetes 集群,对于理解 Kubernetes 组件的架构很有帮助。从实现来看,kubeadm 简洁明了,对于最新版的 Kubernetes 支持很好,而且和 k8s 一样是用 Golang 写的,只需要运行一个二进制文件就行,很轻便。深入代码,我们会发现 kubeadm 完全没有如何考虑部署高可用的 Kubernetes 集群,比如创建 kube-proxy 不能配置 kube-apiserver 的地址;运行 kubeadm join 时不能对 kube-apiserver 地址进行配置。几乎没有实现高可用的扩展性,这一功能的缺失使得整个社区诞生了很多的更复杂的部署工具,有的基于 Ansible,有的是提供了一堆 shell 脚本,还有的基于 kubeadm 做修改,有的甚至用 Golang 重新写了一套部署工具,百花齐放,只是质量参差不齐,光是调研这些工具就需要很多成本和精力。值得期待的是,kubeadm 在往『部署高可用集群』这个方向努力,等到这个特性稳定之后有机会我们再研读一下代码。
References